[toc]

Flutter iOS 点击状态栏回到顶部

一、会回到顶部的原理

ios中一个常见的交互是:点击顶部栏时,自动将当前的滚动区滚到顶部。在flutter中,大部分时候这件事是“自然完成”的,但是也有时候会遇到这个行为失效的情况。要解决这个问题首先自然是要看这个feature是如何实现的。
其实大部分都是Scaffold里面干的事:
Scaffold里有这样一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
switch (themeData.platform) {
case TargetPlatform.iOS:
_addIfNonNull(
children,
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleStatusBarTap,
// iOS accessibility automatically adds scroll-to-top to the clock in the status bar
excludeFromSemantics: true,
),
_ScaffoldSlot.statusBar,
removeLeftPadding: false,
removeTopPadding: true,
removeRightPadding: false,
removeBottomPadding: true,
);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
break;
}

这里命名很舒服,可以直接看出来在干什么:如果是ios的话,那就给Scaffold加一个在状态栏上的点击区,点击的话就会触发一个函数,这个函数干的事情如下:

1
2
3
4
5
6
7
8
9
10
11
final ScrollController _primaryScrollController = ScrollController();

void _handleStatusBarTap() {
if (_primaryScrollController.hasClients) {
_primaryScrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 300),
curve: Curves.linear, // TODO(ianh): Use a more appropriate curve.
);
}
}

也就是,Scaffold会提供一个默认的ScrollController,而点击顶部栏会使得这个controller滚到顶部,在ScrollView的build函数中则会取这个controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@override
Widget build(BuildContext context) {
final List<Widget> slivers = buildSlivers(context);
final AxisDirection axisDirection = getDirection(context);

// 注意,此处的primary不是传入的primary,
// primary = primary ?? controller == null && identical(scrollDirection, Axis.vertical)
final ScrollController scrollController = primary
? PrimaryScrollController.of(context)
: controller;
final Scrollable scrollable = Scrollable(
dragStartBehavior: dragStartBehavior,
axisDirection: axisDirection,
controller: scrollController,
physics: physics,
semanticChildCount: semanticChildCount,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return buildViewport(context, offset, axisDirection, slivers);
},
);
return primary && scrollController != null
? PrimaryScrollController.none(child: scrollable)
: scrollable;
}

如果指定controller的话,就不会使用PrimaryScrollview,如果不指定的话,则在primary为true时使用PrimaryController,而默认情况下controller为null,primary为true,因此一个裸体的ListView是会相应屏幕点击的。
知道了原理,就很容易分析自己代码里出的问题是什么,常见的可能就是:
1、没加Scaffold,这个其实并不常见(自相矛盾草),不过可以检查一下,一般总是会有Scaffold的
2、真正常见的:指定了controller,如果自己创建了一个Controller丢给ScrollView,那必然是会失效的。但是使用controller又是一个很常见且重要的需求,怎么办呢?也很简单,就是不要自己创建新的ScrollController,而是直接取PrimaryScrollController.of(context)这个controller,对其进行自己要做的操作。
3、相对不太常见且需要分析具体代码的:多个Scaffold导致的冲突。
注意到其实flutter里的这个点击状态栏并不是真的点击了状态栏,而是点击了“Scaffold提供的位于状态栏的可点击区域”,也就是说,如果有多个Scaffold就会有多个这样的区域。实际情况是,只有最内部的Scaffold的状态栏会有响应,而如果ScrollView所处位置取到的和点击的Scaffold不一致,自然也就不会有滚动到顶部的feature

二、错误示例

今天遇到ios点击状态栏无法回到顶部(原理在文章后)的问题。研究后发现,Scaffold组件虽然会自带这个功能。但使用时候,必须遵循指定规则才行。

我们以点击 TapStatusNormalPage 上的状态栏返回顶部来举例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// tap_status_normal_page.dart

import 'package:flutter/material.dart';

class TapStatusNormalPage extends StatefulWidget {
const TapStatusNormalPage({Key key}) : super(key: key);

@override
State<TapStatusNormalPage> createState() => _TapStatusNormalPageState();
}

class _TapStatusNormalPageState extends State<TapStatusNormalPage> {
@override
Widget build(BuildContext context) {
return Scaffold( // 注意这个页面已经包了 Scaffold 了
appBar: AppBar(
title: const Text('状态栏点击-Normal5'),
),
body: ListView.builder(
itemCount: 200,
itemBuilder: (context, index) {
return Text('$index');
},
),
);
}
}

错误一:如果Scaffold里面又套了一个Scaffold,那么这个回到顶部就会失效。

失效示例1:

1
2
3
4
5
6
7
8
9
10
import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: TapStatusNormalPage(), // 失效原因:TapStatusNormalPage里已经有 Scaffold 了
);
}
}

这种情况,点击状态栏便不会回到顶部,我们需要保证的就是每个页面仅有一个Scaffold。

错误二:app的builder的Widget生成,如果child再多余包Scaffold会导致点击状态栏无法回到顶部

失效示例2:

虽然home里只有一个 Scaffold ,但app的builder的Widget生成,如果child再多余包Scaffold会导致点击状态栏无法回到顶部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
return ScreenUtilInit(
designSize: const Size(375, 812),
builder: () => MaterialApp(
navigatorKey: navigatorKey,
title: 'wish',
home: TapStatusNormalPage(),
builder: EasyLoading.init(builder: (context, widget) {
return MediaQuery(
///设置文字大小不随系统设置改变
data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
// child: Scaffold( // 注意:app的builder的Widget生成,如果child再多余包Scaffold会导致点击状态栏无法回到顶部
// resizeToAvoidBottomInset: false,
// body: widget,
// ),
child: widget, // OK
);
}),
),
);

错误三:一说要滚动就自定义controller

点击页面上的某个按钮,让页面上的列表滚动到顶部(不需要自定义controller)。

1
2
3
4
5
6
7
8
PrimaryScrollController.of(context).jumpTo(0);


PrimaryScrollController.of(context).animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.linear,
);

如果还要监听滚动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@override
void didChangeDependencies() {
super.didChangeDependencies();

if (!_hasEverInitListener) {
PrimaryScrollController.of(context).addListener(_handleScrollViewEvent);
_hasEverInitListener = true;
}
}

@override
void deactivate() {
super.deactivate();
PrimaryScrollController.of(context).removeListener(_handleScrollViewEvent);
}


_handleScrollViewEvent() {
// 滚动距离
double offsetY = PrimaryScrollController.of(context).offset;


}

错误四:Another exception was thrown: ScrollController attached to multiple scroll views.

问题:

flutter_swiper:Another exception was thrown: ScrollController attached to multiple scroll views

翻译一下:引发了另一个异常:ScrollController连接到多个滚动视图。

原因:

Flutter Swiper是一个轮播图组件,内部包含一个Widget List,当这个Widget List数量大于1,就可能会有这种情况

解决方案:给Swiper加一个Key即可解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
return Container(
child: AspectRatio(
aspectRatio: 1.5 / 1, // 宽高比450/300
child: Swiper(
key: UniqueKey(), // 这个必须添加,代表唯一
itemBuilder: (BuildContext context, int index) {
return new Image.network(
imgList[index]['url'],
fit: BoxFit.fill,
);
},
itemCount: imgList.length,
pagination: new SwiperPagination(),
control: new SwiperControl(),
autoplay: true,
),
),
);

End